Skip to content

[feat] 자동 정산 기능 최적화 #146

Merged
gkdudans merged 21 commits intodevelopfrom
refactor/settlement/gkdudans-kafka2
Sep 16, 2025
Merged

[feat] 자동 정산 기능 최적화 #146
gkdudans merged 21 commits intodevelopfrom
refactor/settlement/gkdudans-kafka2

Conversation

@gkdudans
Copy link
Contributor

@gkdudans gkdudans commented Sep 15, 2025

#️⃣ Issue Number

📝 요약(Summary)

  • 트랜잭션 범위 최소화 및 가상스레드 기반 비동기 처리
  • 원자적 연산 보장을 위한 Redis Lua Script 도입
  • Kafka, Outbox Pattern으로 Event Sourcing 아키텍처 구현
    • 추후 실패 및 재시도 로직에 대한 보완 필요

🛠️ PR 유형

어떤 변경 사항이 있나요?

  • 새로운 기능 추가
  • 버그 수정
  • CSS 등 사용자 UI 디자인 변경
  • 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
  • 코드 리팩토링
  • 주석 추가 및 수정
  • 문서 수정
  • 테스트 추가, 테스트 리팩토링
  • 빌드 부분 혹은 패키지 매니저 수정
  • 파일 혹은 폴더명 수정
  • 파일 혹은 폴더 삭제

📸스크린샷 (선택)

💬 공유사항 to 리뷰어

✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

  • 커밋 메시지 컨벤션에 맞게 작성했습니다.
  • 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트).

Summary by CodeRabbit

  • 신규 기능

    • 정산을 이벤트·아웃박스 기반 비동기 워크플로로 전환하고 Kafka 연동(토픽·프로듀서·컨슈머) 및 배치 발행/처리 파이프라인을 추가했습니다.
    • 장애/성공 이벤트를 기록·전송하는 아웃박스 및 재시도/배칭 처리기, Kafka 소비자·쓰기기(ledger)와 연동되는 이벤트 리스너를 도입했습니다.
  • 개선 사항

    • 금액/잔액 타입을 int→Long으로 확장해 큰 금액 안전 처리.
    • Redis 기반 분산 게이트로 지갑 동시성 제어 및 중복 방지 강화.
    • 가상 스레드 기반 제한된 병렬 실행기 도입, 도커 이미지·런타임 설정 정리.
  • 기타

    • 새 에러 코드와 운영 토픽/설정 항목이 추가되었습니다.

@coderabbitai
Copy link

coderabbitai bot commented Sep 15, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

정산/지갑 도메인을 int→Long으로 확장하고 이벤트 기반 아웃박스→카프카 처리 흐름(아웃박스 엔티티·레포·릴레이·리스너·원장 기록기)과 관련한 Kafka/Redis/Async 인프라·설정을 대거 추가했습니다. 빌드·도커 설정과 QueryDSL, 의존성도 확장되었습니다.

Changes

Cohort / File(s) Summary
Build & Infra
Dockerfile, .dockerignore, .gitignore, build.gradle
Docker 베이스 이미지·JAR 복사·JVM 프리뷰 변경, .dockerignore/.gitignore 항목 추가, Gradle에 QueryDSL·Kafka·Redis 등 의존성·생성소스·컴파일/테스트/bootRun --enable-preview 설정 추가.
Kafka Config
src/.../global/config/kafka/KafkaProperties.java, .../KafkaConsumerConfig.java, .../KafkaProducerConfig.java, .../KafkaTopicConfig.java, .../KafkaErrorConfig.java
Kafka 프로퍼티 바인딩 클래스 추가 및 Producer/Consumer/토픽/에러핸들러/보안·트랜잭션 설정 빈 추가.
Outbox Pattern
src/.../settlement/dto/event/OutboxEvent.java, src/.../settlement/entity/OutboxStatus.java, src/.../settlement/repository/OutboxRepository.java, src/.../settlement/service/OutboxAppender.java, src/.../settlement/service/FailedEventAppender.java, src/.../global/config/kafka/OutboxRelayConfig.java
Outbox 엔티티·상태·레포지토리와 아펜더 서비스 추가, 주기적 릴레이로 Outbox → Kafka 발행 및 상태 갱신 구현.
Settlement Event Flow
src/.../settlement/service/SettlementService.java, .../SettlementKafkaEventListener.java, .../SettlementProcessEventListener.java, .../settlement/dto/event/SettlementProcessEvent.java, .../settlement/dto/event/*
즉시 정산(동기 지갑 처리) 제거, SettlementProcessEvent 아웃박스 발행으로 전환; 이벤트 수신자·병렬 참가자 처리·완료 로직 분리.
Ledger Writer & Kafka Consumer
src/.../settlement/service/KafkaService.java, src/.../settlement/service/LedgerWriter.java, src/.../wallet/repository/WalletTransactionRepository.java
사용자 정산 결과 토픽 배치 소비 및 LedgerWriter 도입: 파싱·중복검사·배칭·충돌대응 로직 추가.
Redis Gate & Async
src/.../wallet/service/RedisLuaService.java, src/.../global/config/RedisLuaConfig.java, src/.../global/config/AsyncConfig.java, src/main/resources/luascript/*
Redis Lua 기반 게이트(획득/해제) 스크립트·빈 추가 및 가상스레드 기반 제한형 Executor(세마포어 바운드) 제공.
Type Widening (int → Long)
src/.../wallet/entity/*, src/.../wallet/service/WalletService.java, src/.../payment/service/PaymentService.java, src/.../schedule/*, src/.../user/dto/*, src/.../wallet/dto/*, src/.../settlement/entity/Settlement.java, src/.../schedule/service/ScheduleService.java
금액·잔액·비용 관련 필드 및 메서드 시그니처를 int→Long(일부 primitive→Wrapper 포함)으로 변경.
WalletTransaction Changes
src/.../wallet/entity/WalletTransaction.java, .../wallet/repository/WalletTransactionRepository.java
WalletTransaction에 operationId 컬럼(유니크) 추가, amount·balance를 Long으로 변경 및 operationId 존재 조회 메서드 추가.
New Services
src/.../settlement/service/UserSettlementService.java, src/.../settlement/service/FailedEventAppender.java
참가자 단위 정산 처리(UserSettlementService)와 실패 이벤트를 별도 트랜잭션으로 Outbox에 기록하는 FailedEventAppender 추가.
Outbox → Kafka Relay
src/.../global/config/kafka/OutboxRelayConfig.java
Outbox를 폴링해 이벤트별 토픽으로 일괄 전송하고 상태를 PUBLISHED로 갱신하는 릴레이 로직 추가.
Error Codes
src/.../global/exception/ErrorCode.java
ALREADY_COMPLETED_SETTLEMENT, WALLET_OPERATION_IN_PROGRESS, INVALID_TOPIC, INVALID_EVENT_PAYLOAD 에러 코드 추가.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client as 호출자
  participant SS as SettlementService
  participant OA as OutboxAppender
  participant OR as OutboxRepository
  participant ORelay as OutboxRelayConfig
  participant KT as KafkaTemplate
  participant SKL as SettlementKafkaEventListener
  participant USS as UserSettlementService
  participant WS as WalletService
  participant SR as SettlementRepository
  participant SchR as ScheduleRepository

  Client->>SS: automaticSettlement(clubId, scheduleId)
  SS->>OA: append("Settlement", settlementId, "SettlementProcessEvent", key, payload)
  OA->>OR: save(OutboxEvent status=NEW)

  rect rgb(220,240,255)
    ORelay->>OR: pickNewForUpdateSkipLocked(n)
    ORelay->>KT: executeInTransaction(send(topic, payload))
    ORelay->>OR: update status=PUBLISHED + publishedAt
  end

  KT-->>SKL: Kafka batch(records)
  SKL->>SKL: parse events, 병렬 처리(세마포어/재시도)
  loop 참가자별
    SKL->>USS: processParticipantSettlement(...)
    USS->>WS: capture/commit wallet (REQUIRES_NEW)
    USS->>OA: append(UserSettlementStatusEvent SUCCESS/FAILED)
  end
  SKL->>USS: creditToLeader(totalAmount)
  SKL->>SchR: schedule CLOSE
  SKL->>SR: markCompleted(COMPLETED, time)
Loading
sequenceDiagram
  autonumber
  participant K as Kafka(UserSettlementResult)
  participant KS as KafkaService
  participant LW as LedgerWriter
  participant WTR as WalletTransactionRepository
  participant TR as TransferRepository
  participant WR as WalletRepository
  participant USR as UserSettlementRepository

  K-->>KS: batch(records)
  KS->>LW: writeBatch(records)
  LW->>WTR: findExistingOperationIds(opIds)
  alt 신규 이벤트
    LW->>WR: find wallets
    LW->>USR: find userSettlement
    LW->>WTR: saveAll(walletTransactions)
    LW->>TR: saveAll(transfers)
  else 중복 발견
    LW->>WTR: insertIndividuallyIgnoringDuplicate(...)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120+ minutes

Possibly related PRs

Suggested labels

Schedule

Suggested reviewers

  • ghkddlscks19
  • doehy
  • geleego
  • NamYeonW00
  • choigpt

Poem

밭을 뛰는 토끼가 말했네, 🥕
숫자를 길게 바꿨더니 더 넉넉해졌고,
작은 Outbox 씨앗 톡! 심어 카프카 바람 탔네.
레저는 쌓이고, 게이트는 락! 풀리네 — 토끼 깡총.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.62% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed 제목 "[feat] 자동 정산 기능 최적화"는 PR의 핵심 목적(자동 정산 로직의 구조 개선과 성능/신뢰성 향상)을 간결하게 요약하고 있어 변경사항과 관련성이 높습니다. 기능 태그([feat])를 포함해 변경 유형을 명시했고 불필요한 파일 목록이나 이모지 없이 깔끔하게 작성되어 스캔하기 쉽습니다. 따라서 팀원이 이력에서 PR의 주된 의도를 빠르게 파악할 수 있습니다.
Description Check ✅ Passed PR 설명은 요약(트랜잭션 범위 최소화, Redis Lua, Kafka/Outbox 등), PR 유형 선택, 체크리스트를 포함해 템플릿 구조를 대부분 충족하며 주요 변경사항을 명확히 기술하고 있습니다. 다만 '## #️⃣ Issue Number'가 기입되어 있지 않고 '💬 공유사항 to 리뷰어'에 검토 포인트나 배포/마이그레이션 영향 등이 비어 있어 리뷰어가 집중해야 할 영역이 누락되어 있습니다. 전반적으로 필요한 정보는 충분히 포함되어 있어 설명은 대체로 완전하다고 판단됩니다.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3cd7da7 and eb5f87c.

📒 Files selected for processing (3)
  • build.gradle (3 hunks)
  • src/main/java/com/example/onlyone/domain/user/service/UserService.java (2 hunks)
  • src/main/java/com/example/onlyone/global/exception/ErrorCode.java (3 hunks)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java (2)

85-95: 모든 예외에서 reportFail 호출은 오탐 위험(승인 성공 후 네트워크 오류 등).

FeignException 일반/기타 예외에서 실패를 확정 기록하면, 실제로는 결제가 성공했지만 네트워크/일시적 오류로 실패로 잘못 남을 수 있습니다. 4xx(특히 BadRequest)만 실패 확정, 나머지는 재확인/재시도 경로로 두세요. 위 첫 코멘트의 diff 참고.


78-96: 트랜잭션 밖으로 Feign(토스) 호출 분리 — 즉시 수정 필요

  • 현황: PaymentService에 클래스 레벨 @transactional이 있어 confirm()이 DB 트랜잭션을 연 상태에서 tossPaymentClient.confirmPayment(...)를 호출합니다 (src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java — 클래스 @transactional: line 41). reportFail(...)은 이미 @transactional(propagation = Propagation.REQUIRES_NEW)입니다 (same file, reportFail: line 165).
  • 조치(필수):
    • confirm()에서 외부 호출을 트랜잭션이 suspend된 상태로 수행하도록 변경: @transactional(propagation = Propagation.NOT_SUPPORTED) 또는 외부 호출만 별도 non-tx 메서드로 분리.
    • DB 락/생성/갱신(특히 claimPayment(), 지갑 잔액 반영, WalletTransaction 기록)은 별도 트랜잭션으로 분리하여(confirm 전/후) 실행 — claimPayment가 락을 필요로 하면 반드시 REQUIRES_NEW 또는 적절한 트랜잭션 경계 안에서 실행하도록 리팩터링.
    • reportFail은 현재 REQUIRES_NEW이므로 유지해도 됨.
  • 주의: catch 블록에서 reportFail 호출을 단순 제거하는 것은 실패 확정(정책)을 변경하는 행위로, transient vs permanent 실패 정책이 확정된 후에만 적용하십시오.
src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (1)

12-14: operationId 조회 성능/멱등성 보장: 인덱스(또는 유니크) 추가 권장.

Outbox/레저 중복 방지 키로 쓰인다면 DB 레벨 유니크 제약/인덱스가 필요합니다.

-@Table(name = "wallet_transaction")
+@Table(
+    name = "wallet_transaction",
+    indexes = {
+        @Index(name = "ix_wallet_tx_operation_id", columnList = "operation_id")
+    }
+)
🧹 Nitpick comments (51)
.gitignore (3)

67-67: Redis 아티팩트 전반 무시 패턴으로 확장 권장

dump.rdb 하나만 무시하면 append-only 파일(AOF)·임시 RDB 등이 남습니다. Redis 버전/설정에 따라 파일명이 달라지므로 포괄 패턴으로 관리하는 편이 안전합니다.

적용 제안(diff):

-redis-data/dump.rdb
+redis-data/*.rdb
+redis-data/temp-*.rdb
+redis-data/*.aof
+redis-data/appendonly*.aof*

운영/빌드 컨텍스트 축소를 위해 동일 패턴을 .dockerignore에도 반영하는 것을 권장합니다.


69-69: stage 환경 설정 무시 OK — 템플릿 파일을 버전에 포함하세요

실파일은 무시하되 팀 합의를 위해 예시 템플릿을 추적하는 것을 권장합니다.

적용 제안(diff):

 src/main/resources/application-stage.yml
+!src/main/resources/application-stage.example.yml

README/운영 문서에 “example를 복사해 실제 값을 채우고 파일명은 application-stage.yml로” 절차를 추가해 주세요. CI가 stage 프로파일을 요구한다면 환경변수/시크릿 기반 주입으로 전환하는 것도 고려 바랍니다.


71-71: 부하 테스트 스크립트는 추적 유지 권장; 결과물만 무시

테스트 스크립트(k6/settlement_test.js)를 무시하면 팀 공유·재현성이 떨어집니다. 스크립트는 추적하고 결과 디렉터리만 무시하세요.

적용 제안(diff):

-k6/settlement_test.js
+k6/results/**
+k6/*.html

이미 커밋된 스크립트를 그대로 유지하고 결과물만 무시하면 됩니다. 의도적으로 제외한 것이라면 사유를 PR 설명에 명시해 주세요.

.dockerignore (1)

1-6: Docker 빌드 컨텍스트 더 줄이기(빌드 산출물만 포함).

현재 build 디렉터리가 제외되지 않아 컨텍스트가 커질 수 있습니다. 빌드 산출물 JAR만 포함하도록 아래 예외 규칙을 추가하는 것을 권장합니다.

 .gradle
 target
 out
 node_modules
 *.iml
 *.log
+build
+!build/libs/*.jar
+.git
+.idea
+*.class
+.DS_Store
src/main/java/com/example/onlyone/domain/schedule/dto/response/ScheduleDetailResponseDto.java (1)

18-18: Long 전환은 적절. 단, null 불가 보장 필요.

Line 18: DTO에서 Long(래퍼) 사용 시 null이 직렬화/클라이언트 파싱 문제를 일으킬 수 있습니다. 생성 경로에서 항상 값 세팅을 보장하거나, Bean Validation(@NotNull)로 컨트롤러 입력을 검증해 주세요. 금액 단위(KRW, 분 단위 등)도 주석/스키마로 명시 권장.

src/main/java/com/example/onlyone/domain/user/dto/response/MySettlementDto.java (1)

16-16: amount Long 전환 OK. 주석된 from(...)는 구 스펙 혼동 유발 → 제거/갱신 권장.

Line 16: Long 사용은 일관성 측면에서 좋습니다. null 허용 시 기본값(0L) 정책을 명확히 해 주세요.
Lines 22-30: 주석 블록이 현재 필드명과 불일치(예: settlementId)합니다. 제거하거나 최신 스펙으로 갱신해 주세요.

Also applies to: 22-30

src/main/java/com/example/onlyone/domain/user/dto/response/MyPageResponse.java (1)

35-35: balance Long 전환 OK. 초기값 보장 필요.

Line 35: 클라이언트 호환성을 위해 null 대신 0L을 반환하는 정책을 권장합니다. DTO 빌더 기본값 또는 응답 조립 시 기본값 세팅을 고려해 주세요.

-    private Long balance;
+    @Builder.Default
+    private Long balance = 0L;
src/main/java/com/example/onlyone/domain/schedule/entity/Schedule.java (1)

41-41: Long 전환 OK. 금액 유효성 제약(0 이상) 명시 권장.

Line 41: 도메인 제약을 엔티티 수준에서도 명시하면 안정적입니다(예: 0 이상).

+import jakarta.validation.constraints.PositiveOrZero;
...
-    @NotNull
-    private Long cost;
+    @NotNull
+    @PositiveOrZero
+    private Long cost;
src/main/resources/luascript/wallet_gate_acquire.lua (1)

2-3: TTL 검증 추가 제안(에러 예방).

EX 0/음수 전달 시 Redis SET이 에러를 던질 수 있어, Lua에서 사전 검증을 권장합니다. 반환 타입은 그대로 유지합니다.

--- KEYS[1] = gate key, ARGV[1] = ttlSec, ARGV[2] = owner
-local ok = redis.call('set', KEYS[1], ARGV[2], 'EX', ARGV[1], 'NX')
-if ok then return 1 else return 0 end
+local ttl = tonumber(ARGV[1])
+if ttl == nil or ttl <= 0 then return 0 end
+local ok = redis.call('set', KEYS[1], ARGV[2], 'EX', ttl, 'NX')
+if ok then return 1 else return 0 end
src/main/java/com/example/onlyone/domain/wallet/entity/Wallet.java (1)

47-49: updateBalance(Long)에서 null/음수 수용 — 방어 로직 추가 권장

파라미터를 primitive로 바꾸고 음수 방지 체크를 추가하세요.

-    public void updateBalance(Long balance) {
-        this.postedBalance = balance;
-    }
+    public void updateBalance(long balance) {
+        if (balance < 0) {
+            throw new IllegalArgumentException("balance must be >= 0");
+        }
+        this.postedBalance = balance;
+    }
src/main/java/com/example/onlyone/domain/schedule/dto/request/ScheduleRequestDto.java (1)

21-22: 금액 Long 전환 👍; 검증은 @PositiveOrZero가 더 표현력 있음

의미상 더 직관적인 애노테이션으로 교체를 제안합니다. 동작은 동일합니다.

-    @Min(value = 0, message = "정기 모임 금액은 0원 이상이어야 합니다.")
-    private Long cost;
+    @PositiveOrZero(message = "정기 모임 금액은 0원 이상이어야 합니다.")
+    private Long cost;
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)

6-15: 이벤트 역직렬화 호환성 확보(@NoArgsConstructor 추가 권장)

Kafka/Outbox에서 Jackson 역직렬화를 한다면 기본 생성자가 없어서 실패할 수 있습니다. 무해한 기본 생성자를 추가하세요.

-import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;

 @Data
 @AllArgsConstructor
+@NoArgsConstructor
 public class WalletCaptureSucceededEvent {
src/main/java/com/example/onlyone/global/config/RedisLuaConfig.java (1)

9-9: 불필요한 import 제거

사용되지 않는 java.util.List import를 제거해 주세요.

-import java.util.List;
src/main/java/com/example/onlyone/global/exception/ErrorCode.java (1)

84-84: 409(중복/경합) 재시도 가이던스 제안

WALLET_OPERATION_IN_PROGRESS는 클라이언트 재시도가 전제됩니다. 응답 헤더에 Retry-After(또는 바디에 재시도 지연 힌트)를 싣도록 API 계층에서 표준화하면 UX/트래픽에 이점이 있습니다.

src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (1)

8-19: Jackson 역직렬화 호환성 보완 권장

final 필드 + all‑args만 있을 경우 환경에 따라 역직렬화 실패가 납니다. 안전하게 기본 생성자를 추가하세요(접근 제한+force).

-import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AccessLevel;
@@
-@Data
-@AllArgsConstructor
+@Data
+@AllArgsConstructor
+@NoArgsConstructor(force = true, access = AccessLevel.PROTECTED)
 public class SettlementProcessEvent {

대안: @Builder + @Jacksonized 조합도 고려 가능합니다.

src/main/java/com/example/onlyone/domain/settlement/repository/SettlementRepository.java (1)

28-34: JPQL에서 enum 비교를 FQN enum 상수로 변경하세요

검증: Settlement.totalStatus는 src/main/java/com/example/onlyone/domain/settlement/entity/Settlement.java에서 @Enumerated(EnumType.STRING)로 매핑되어 있어 현재 쿼리는 정상 동작합니다. 다만 매핑이 ORDINAL로 변경되면 쿼리가 깨지므로 JPQL에서 문자열 리터럴('COMPLETED') 대신 FQN enum 상수로 비교하도록 변경하세요.

위치: src/main/java/com/example/onlyone/domain/settlement/repository/SettlementRepository.java (lines 28-34)

-    @Query("UPDATE Settlement s " +
-            "SET s.totalStatus = :status, s.completedTime = :time " +
-            "WHERE s.settlementId = :id AND s.totalStatus <> 'COMPLETED'")
+    @Query("UPDATE Settlement s " +
+            "SET s.totalStatus = :status, s.completedTime = :time " +
+            "WHERE s.settlementId = :id AND s.totalStatus <> com.example.onlyone.domain.settlement.entity.TotalStatus.COMPLETED")
     int markCompleted(@Param("id") Long id,
                       @Param("status") TotalStatus status,
                       @Param("time") LocalDateTime time);
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)

12-12: 데이터 타입 일관성 검토가 필요합니다.

amount 필드가 int 타입인데, 다른 이벤트 클래스들과 PR 전반의 변경사항을 보면 금액 필드가 Long으로 변경되고 있습니다. 일관성을 위해 Long 타입 사용을 검토해보세요.

-    private int amount;
+    private Long amount;
src/main/java/com/example/onlyone/domain/wallet/dto/response/UserWalletTransactionDto.java (1)

20-21: amount의 Long 전환 OK. 프런트/외부 API와 정수 범위 합의 필요.

JS Number의 안전 정수 범위(±9,007,199,254,740,991) 이슈가 있을 수 있습니다. 금액이 해당 범위를 넘지 않는지, 또는 직렬화 정책(예: 문자열) 합의가 되어있는지 확인해 주세요.

Also applies to: 29-31

src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java (1)

126-163: 오토 언박싱 NPE 가능성 제거.

claimPayment(String orderId, long amount)은 primitive long을 받습니다. req.getAmount()가 null이면 NPE가 납니다. DTO에 @NotNull 보장 또는 시그니처를 Long으로 바꾸고 null 검증을 추가하세요.

-    private Payment claimPayment(String orderId, long amount) {
+    private Payment claimPayment(String orderId, Long amount) {
+        if (amount == null) throw new CustomException(ErrorCode.INVALID_PAYMENT_INFO);
src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (2)

33-40: 널 제약을 DB 레벨까지 반영.

현재 @NotNull만 있고 @column(nullable = false)가 없어 DDL에 반영되지 않을 수 있습니다.

-    @Column(name = "amount")
+    @Column(name = "amount", nullable = false)
     @NotNull
     private Long amount;

-    @Column(name = "balance")
+    @Column(name = "balance", nullable = false)
     @NotNull
     private Long balance;

Also applies to: 41-45


81-88: update(...)에서 targetWallet 강제 동일 설정은 범용성 저하.

충전 이외(이체 등)에서 targetWallet이 다를 수 있는데, 현재 메서드는 동일 지갑으로 덮어씌웁니다. 호출부에서 명시하지 않으면 오동작 위험이 있습니다. 파라미터 분리 또는 타입별 분기로 개선하세요.

-    public void update(Type type, Long amount, Long postedBalance, WalletTransactionStatus walletTransactionStatus, Wallet wallet) {
+    public void update(Type type, Long amount, Long postedBalance, WalletTransactionStatus walletTransactionStatus, Wallet wallet) {
         this.type = type;
         this.amount = amount;
         this.balance = postedBalance;
         this.walletTransactionStatus = walletTransactionStatus;
         this.wallet = wallet;
-        this.targetWallet = wallet;
+        // CHARGE인 경우에만 동일 지갑 설정(필요 시 호출부에서 명시적으로 설정)
+        if (type == Type.CHARGE) {
+            this.targetWallet = wallet;
+        }
     }
src/main/java/com/example/onlyone/domain/settlement/dto/event/UserSettlementStatusEvent.java (1)

9-17: 역직렬화/호환성 강화를 위한 NoArgsConstructor 추가 및 타입 일관성.

다른 서비스/컨슈머에서 Jackson 역직렬화 시 기본 생성자가 없으면 실패할 수 있습니다. 또한 금액 타입을 Long으로 통일하면 내부 도메인과 일관성이 높습니다.

 @Data
-@AllArgsConstructor
+@AllArgsConstructor
+@lombok.NoArgsConstructor
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class UserSettlementStatusEvent {
     public enum ResultType { SUCCESS, FAILED }
@@
-    private long amount;
+    private Long amount;
@@
     @Data
     public static class Snapshots {
         private Long memberPostedBalance;
         private Long leaderPostedBalance;
     }

Also applies to: 29-34

src/main/java/com/example/onlyone/global/config/kafka/KafkaErrorConfig.java (1)

14-21: @qualifier 불필요 — KafkaTemplate 빈은 단일입니다.

src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java:57 에 ledgerKafkaTemplate() 빈 한 개 확인. 따라서 NoUniqueBeanDefinitionException 우려 없음. DefaultErrorHandler에 handler.addNotRetryableExceptions(IllegalArgumentException.class) 등록은 선택적 권장.

src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (2)

26-30: groupId 하드코딩 → 구성 일원화 필요

컨슈머 팩토리에서 props의 groupId를 이미 사용 중인데, @KafkaListener에 "ledger-writer"로 하드코딩되어 불일치가 날 수 있습니다(운영/스테이징 분기 곤란).

-            groupId = "ledger-writer",
+            groupId = "#{@kafkaProperties.consumer.commonConfig.groupId}",

22-30: 관측성 보강 제안(배치 크기/파트션/오프셋 로그 및 메트릭)

운영 트러블슈팅을 위해 records.size(), topic/partition, first/last offset 등을 info/debug로 남기거나 micrometer 카운터 추가를 권장합니다.

src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (3)

23-25: 스케줄 주기 하드코딩

fixedDelay 200ms는 환경별로 튜닝이 필요합니다. 프로퍼티화하여 운영에서 조정 가능하도록 하세요.

-    @Scheduled(fixedDelay = 200)
+    @Scheduled(fixedDelayString = "${outbox.relay.fixed-delay-ms:200}")

추가로 배치 크기도 프로퍼티로 외부화 권장.


36-40: 상태 전이 실패 시 상태 기록 부재

send 단계 예외 발생 시 상태가 그대로 NEW로 남아 무한 재시도는 가능하지만, 실패 원인/횟수 추적이 어렵습니다.

  • 실패 카운터/lastError 필드 추가 또는 로그/메트릭에 원인 기록.
  • 필요 시 FAILED 상태 도입과 백오프 재시도.

42-50: routeTopic에서 프로듀서/컨슈머 설정 혼용

ParticipantSettlementResult를 컨슈머 설정(props.consumer.userSettlementLedgerConsumerConfig.topic)에서 가져오고, SettlementProcessEvent는 프로듀서 설정에서 가져옵니다. 구성 일관성이 떨어집니다.

  • 토픽 소스를 단일 영역(예: props.topics.* 또는 producer 쪽)으로 통일 검토를 요청드립니다.

이 변경이 다른 환경(yaml)에서 정상 값으로 해석되는지 확인 부탁드립니다.

src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java (1)

83-109: Long 전환은 적절하나, 잔액 스냅샷/operationId 확인 필요

  • balance를 postedBalance 스냅샷으로 저장하는 현재 방식이 요구사항(정산 시점 잔액)과 일치하는지 확인 바랍니다.
  • 새 엔티티에 operationId 필드가 도입된 것으로 보입니다. 본 경로로 생성되는 트랜잭션에도 멱등 식별자(operationId) 부여가 필요한지 검토하십시오(중복 생성 방지).

가능 시 프록시 조회로 일관화:

-        Wallet wallet = walletRepository.findById(walletId).orElseThrow();
-        Wallet leaderWallet = walletRepository.findById(leaderWalletId).orElseThrow();
+        Wallet wallet = walletRepository.getReferenceById(walletId);
+        Wallet leaderWallet = walletRepository.getReferenceById(leaderWalletId);
src/main/java/com/example/onlyone/domain/settlement/repository/UserSettlementRepository.java (3)

119-128: 메서드명과 반환 값 불일치

SELECT us.user.userId를 반환하는데 메서드명이 findAllUserSettlementIds...라 오해 소지가 큽니다(실제는 participant(user) ID 리스트).

  • 명칭을 findAllParticipantIdsBySettlementIdAndStatus 등으로 변경 권장(호출부 영향 확인 필요).

129-138: 중복 메서드 존재

findActiveParticipantIds가 위 메서드와 완전히 동일 쿼리/시그니처입니다. 유지보수 혼란을 줄이려면 하나만 남기세요(대체 주석에도 “사용하지 않음” 명시).

  • @deprecated 추가 후 제거 또는 즉시 삭제 권장.

93-97: 성능/인덱스 확인 요청

settlementId+settlementStatus 조건으로 반복 조회됩니다. 해당 조합에 대한 복합 인덱스가 있는지 확인해 주세요.

원하시면 JPA/Hibernate 실행 계획을 기반으로 인덱스 제안을 드리겠습니다.

Also applies to: 119-128

src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (3)

20-38: 트랜잭션 전파 전략 재검토

Outbox 이벤트는 일반적으로 도메인 변경과 “같은” 트랜잭션에서 커밋되어야 합니다. 현재 @transactional은 새 트랜잭션을 열 수도 있어, 도메인 변경과 분리될 수 있습니다.

  • 도메인 서비스에서 이미 트랜잭션을 연다는 전제라면 MANDATORY로 고정 권장:
-    @Transactional
+    @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
  • 반대로 단독 사용도 필요하면 Javadoc로 사용 컨벤션 명시 또는 오버로드 제공.

24-37: 예외 래핑 타입 통일

RuntimeException("Outbox append failed") 대신 프로젝트 표준 CustomException(ErrorCode.*) 사용을 검토해 주세요(로그/오브저버빌리티 일관성).


24-31: 페이로드 크기/스키마 검증

대형 페이로드는 Kafka max.request.size/브로커 설정에 막힐 수 있습니다. 직렬화 실패 시 재시도 루프에 들어갈 수 있어, 스키마/크기 밸리데이션(예: 1MB 제한) 추가를 권장합니다.

src/main/java/com/example/onlyone/domain/settlement/dto/event/OutboxEvent.java (2)

1-1: 엔티티가 dto 패키지에 위치

영속 엔티티가 dto.event 패키지에 위치하면 계층 경계가 흐려집니다. entity 패키지로 이동을 권장합니다. 매핑 경로 변경에 따른 import/패키지 의존성만 조정하면 됩니다.


26-34: 시간 필드 기본값/제약과 타입 일관성 정비

createdAt/publishedAt가 null 허용입니다. 운영 가시성과 정합성을 위해 null 금지 + 자동 채움이 낫습니다. 또한 시스템 시간대 영향 최소화를 위해 Instant 사용을 고려하세요.

적용 제안(개요):

  • @PrePersistcreatedAt 자동 세팅, @Column(nullable = false) 부여
  • 필요 시 publishedAt도 상태 전이 시점에서만 세팅
  • DB가 지원한다면 payload를 JSON 타입으로 저장하여 인덱싱/검증 이점 확보
src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (4)

30-33: Kafka 기반 처리와 기능 중복 가능성 점검

동일 기능이 SettlementKafkaEventListener에도 존재합니다. 실제 트리거 경로(스프링 이벤트 vs. 카프카) 중 하나만 활성이라면 비사용 경로는 제거/프로파일링으로 분리해 복잡도를 줄이세요.


56-83: 참가자 루프+재시도 동안 트랜잭션 유지 → 장기 트랜잭션 리스크

메서드에 @Transactional이 걸려 있어 전체 루프(슬립 포함) 동안 트랜잭션이 유지됩니다. 참가자별 처리는 이미 REQUIRES_NEW이므로, 여기서는 최종 집계/상태 갱신 구간만 트랜잭션으로 좁히는 편이 안전합니다.

개선 제안:

  • 루프 부분은 비트랜잭션(또는 readOnly)로 수행
  • 최종 creditToLeader/스케줄/정산 갱신만 별도 메서드(@transactional)로 분리

46-54: 예외 삼키기 방지

실패 시 로깅/모니터링(또는 실패 Outbox) 없이 무시됩니다. 최소한 경고 로그와 컨텍스트(이벤트 ID/settlementId)를 남겨주세요.

적용 제안:

-        } catch (Exception e) {
-            // 필요 시 실패 알림/아웃박스
-        }
+        } catch (Exception e) {
+            log.warn("SettlementProcessEvent handling failed: settlementId={}, scheduleId={}",
+                    event.getSettlementId(), event.getScheduleId(), e);
+            // TODO: 실패 Outbox 기록 또는 알림
+        }

43-45: 미사용 필드 제거

EntityManager em이 사용되지 않습니다. 제거하여 노이즈를 줄이세요.

-    @PersistenceContext
-    private EntityManager em;
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)

51-63: Outbox 저장 로직 중복 – OutboxAppender 사용으로 DRY

동일 책임(직렬화/OutboxEvent 생성/저장)이 OutboxAppender에 이미 있습니다. 중복 로직을 제거해 유지보수성과 일관성을 높여주세요.

다음과 같이 치환 가능합니다:

-            OutboxEvent event = OutboxEvent.builder()
-                    .aggregateType("UserSettlement")
-                    .aggregateId(userSettlementId)
-                    .eventType("ParticipantSettlementResult")
-                    .keyString(String.valueOf(memberWalletId)) // partition key
-                    .payload(json)
-                    .status(OutboxStatus.NEW)
-                    .createdAt(LocalDateTime.now())
-                    .build();
-
-            outboxRepository.save(event);
+            outboxAppender.append(
+                "UserSettlement",
+                userSettlementId,
+                "ParticipantSettlementResult",
+                String.valueOf(memberWalletId), // partition key
+                json
+            );
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)

94-102: 파싱 실패 원인 로깅 보강 제안

현재 INVALID_EVENT_PAYLOAD로만 래핑됩니다. 최소한 원인(e.getMessage())을 로그로 남겨 운영 가시성을 확보하세요.

src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java (3)

120-137: 이벤트 스키마의 타입 안정성 확보 (DTO 사용 권장)

현재 Map으로 페이로드를 구성합니다. SettlementProcessEvent DTO를 그대로 사용하면 스키마 드리프트를 방지하고 직렬화 오류를 줄일 수 있습니다.

-        outboxAppender.append(
-                "Settlement",
-                settlement.getSettlementId(),
-                "SettlementProcessEvent",
-                String.valueOf(settlement.getSettlementId()),
-                Map.of(
-                        "eventId", java.util.UUID.randomUUID().toString(),
-                        "occurredAt", java.time.Instant.now().toString(),
-                        "settlementId", settlement.getSettlementId(),
-                        "scheduleId", scheduleId,
-                        "clubId", clubId,
-                        "leaderId", leader.getUserId(),
-                        "leaderWalletId", leaderWallet.getWalletId(),
-                        "costPerUser", schedule.getCost(),
-                        "totalAmount", totalAmount,
-                        "targetUserIds", targetUserIds
-                )
-        );
+        var evt = new SettlementProcessEvent(
+                java.util.UUID.randomUUID().toString(),
+                java.time.Instant.now(),
+                settlement.getSettlementId(),
+                scheduleId,
+                clubId,
+                leader.getUserId(),
+                leaderWallet.getWalletId(),
+                schedule.getCost(),
+                totalAmount,
+                targetUserIds
+        );
+        outboxAppender.append("Settlement", settlement.getSettlementId(),
+                "SettlementProcessEvent", String.valueOf(settlement.getSettlementId()), evt);

101-105: 0원/무참가자 분기에서 영속화/상태 일관성 보완

schedule.updateStatus(CLOSED)와 removeSettlement 후 즉시 return 합니다. orphanRemoval/flush 보장이 없다면 상태 일관성이 깨질 수 있습니다. 저장 또는 정산 엔티티 상태 갱신을 명시하세요.

         if (schedule.getCost() == 0 || userCount == 0) {
             schedule.updateStatus(ScheduleStatus.CLOSED);
             schedule.removeSettlement(settlement);
-            return;
+            scheduleRepository.save(schedule);
+            settlementRepository.delete(settlement); // 또는 상태를 CANCELED 등으로 업데이트
+            return;
         }

56-58: 미사용 필드 제거 권장

eventPublisher는 사용되지 않습니다. 정리해 경고를 제거하세요.

src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (3)

77-78: 미사용 변수/맵 제거

walletTransactionHashMap은 쓰이지 않습니다. 제거해 가독성과 메모리 사용을 개선하세요.

-        Map<String, WalletTransaction> walletTransactionHashMap = new HashMap<>();
...
-                walletTransactionHashMap.put(outId, outTransaction);
...
-                walletTransactionHashMap.put(inId, inTransaction);

Also applies to: 107-108, 134-135


18-19: 중복 로그 애너테이션 import

@log4j2를 사용 중인데 lombok.extern.java.Log import가 남아있습니다. 정리 바랍니다.

-import lombok.extern.java.Log;

155-169: Transfer 배치 실패 시 로깅/개별 재시도 보강

DataIntegrityViolationException을 무시하면 데이터 손실을 은닉할 수 있습니다. 최소한 경고 로그와 실패 카운트/메트릭을 남기고 필요 시 개별 재시도를 수행하세요.

src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (2)

62-73: 에러 핸들러 일관 설정 제안

DefaultErrorHandler 빈이 있다면 컨테이너에 명시 설정해 운영시 가시성을 높이세요(재시도/DTL 정책 확인 용이).

예:

f.setCommonErrorHandler(defaultErrorHandler());

또한 settlementProcess 컨테이너에도 observationEnabled 설정을 고려해 주세요.

Also applies to: 80-87


12-12: 미사용 import 정리

CommonLoggingErrorHandler는 사용되지 않습니다.

-import org.springframework.kafka.listener.CommonLoggingErrorHandler;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 27d0fd6 and eb7de53.

📒 Files selected for processing (48)
  • .dockerignore (1 hunks)
  • .gitignore (1 hunks)
  • Dockerfile (1 hunks)
  • build.gradle (4 hunks)
  • src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/schedule/dto/request/ScheduleRequestDto.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/schedule/dto/response/ScheduleDetailResponseDto.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/schedule/dto/response/ScheduleResponseDto.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/schedule/entity/Schedule.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/schedule/service/ScheduleService.java (3 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/OutboxEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/UserSettlementStatusEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/entity/OutboxStatus.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/entity/Settlement.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/repository/OutboxRepository.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/repository/SettlementRepository.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/repository/UserSettlementRepository.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java (3 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/user/dto/response/MyPageResponse.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/user/dto/response/MySettlementDto.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/user/service/UserService.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/dto/response/UserWalletTransactionDto.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/entity/Wallet.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/repository/WalletTransactionRepository.java (2 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/service/RedisLuaService.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java (2 hunks)
  • src/main/java/com/example/onlyone/global/config/AsyncConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/RedisLuaConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaErrorConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaProperties.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/exception/ErrorCode.java (3 hunks)
  • src/main/resources/luascript/wallet_gate_acquire.lua (1 hunks)
  • src/main/resources/luascript/wallet_gate_release.lua (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-11T09:51:57.984Z
Learnt from: gkdudans
PR: GoormOnlyOne/OnlyOne-Back#110
File: src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java:5-5
Timestamp: 2025-08-11T09:51:57.984Z
Learning: In the OnlyOne project's wallet transaction system (WalletTransactionStatus enum in src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java), the PENDING status is used as a locking mechanism for concurrency control to prevent duplicate transaction processing, not for retry or timeout handling.

Applied to files:

  • src/main/java/com/example/onlyone/domain/settlement/entity/OutboxStatus.java
📚 Learning: 2025-07-31T06:40:23.207Z
Learnt from: gkdudans
PR: GoormOnlyOne/OnlyOne-Back#53
File: src/main/java/com/example/onlyone/domain/schedule/service/ScheduleService.java:52-62
Timestamp: 2025-07-31T06:40:23.207Z
Learning: In the OnlyOne application's ScheduleService, the user has refactored the updateScheduleStatus scheduled method to use JPQL batch update instead of iterating through individual entities for better performance. They are also considering future improvements with Spring Batch or QueryDSL update functionality for more complex batch processing scenarios.

Applied to files:

  • src/main/java/com/example/onlyone/domain/schedule/service/ScheduleService.java
  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java
📚 Learning: 2025-08-08T02:28:30.742Z
Learnt from: gkdudans
PR: GoormOnlyOne/OnlyOne-Back#96
File: src/main/java/com/example/onlyone/domain/notification/service/NotificationService.java:0-0
Timestamp: 2025-08-08T02:28:30.742Z
Learning: 사용자 gkdudans가 알림 생성 로직에서 트랜잭션 커밋 후 알림 전송을 위해 TransactionalEventListener(phase = AFTER_COMMIT)를 사용하는 것을 선호함. 이는 TransactionSynchronizationManager보다 더 깔끔하고 Spring다운 이벤트 기반 접근법임.

Applied to files:

  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java
🧬 Code graph analysis (17)
src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (2)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (2)
src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (1)
  • Data (8-19)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (2)
src/main/java/com/example/onlyone/global/config/kafka/KafkaErrorConfig.java (1)
  • Configuration (11-23)
src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java (1)
  • Configuration (15-55)
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)
src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1)
  • Service (13-39)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (3)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1)
  • RequiredArgsConstructor (16-60)
src/main/java/com/example/onlyone/global/config/kafka/KafkaErrorConfig.java (1)
  • Configuration (11-23)
src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java (1)
  • Configuration (15-55)
src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1)
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)
  • Service (16-67)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (2)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (1)
  • Component (30-134)
src/main/java/com/example/onlyone/domain/wallet/service/RedisLuaService.java (1)
  • Component (15-74)
src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (4)
src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (1)
  • Log4j2 (28-194)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java (1)
  • Log4j2 (42-153)
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)
  • Service (16-67)
src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1)
  • Service (13-39)
src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java (2)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)
  • RequiredArgsConstructor (20-89)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1)
  • RequiredArgsConstructor (16-60)
src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (2)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)
  • RequiredArgsConstructor (20-89)
src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (1)
  • Log4j2 (28-194)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProperties.java (2)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)
  • Component (34-197)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1)
  • Component (15-51)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (2)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)
  • Component (34-197)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1)
  • Component (15-51)
src/main/java/com/example/onlyone/domain/wallet/service/RedisLuaService.java (2)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (1)
  • Component (30-134)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)
  • Component (34-197)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (2)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)
  • RequiredArgsConstructor (20-89)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1)
  • RequiredArgsConstructor (16-60)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (2)
src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (1)
  • Data (8-19)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/global/config/kafka/KafkaErrorConfig.java (1)
src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1)
  • RequiredArgsConstructor (16-60)
src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (3)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)
  • RequiredArgsConstructor (20-89)
src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (1)
  • Log4j2 (14-42)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1)
  • Component (15-51)
🔇 Additional comments (40)
.gitignore (2)

70-70: k6 결과 산출물 무시는 적절

결과 JSON은 산출물로서 무시하는 설정이 합리적입니다.


68-68: .my.cnf 무시 유지 — 추적되지 않음(검증 완료)

검증 스크립트 결과: .my.cnf는 Git 인덱스에 추적되어 있지 않습니다. 캐시 제거 불필요 — .gitignore 항목 유지.

src/main/resources/luascript/wallet_gate_release.lua (1)

2-6: 원자적 비교-삭제(CAS) 패턴 구현 LGTM.

소유자 일치 시에만 삭제되어 경쟁 상태를 잘 방지합니다.

src/main/java/com/example/onlyone/domain/schedule/entity/Schedule.java (1)

63-69: update 시그니처 변경에 따른 호출부 타입 점검 필요.

Schedule.update의 세 번째 파라미터가 int→Long으로 변경되었습니다. 서비스/컨트롤러/테스트 호출부가 모두 Long을 전달하는지 확인하세요. 현재 검색 결과 ScheduleService에서 아래 호출이 발견됩니다 — src/main/java/com/example/onlyone/domain/schedule/service/ScheduleService.java:171: schedule.update(requestDto.getName(), requestDto.getLocation(), requestDto.getCost(), requestDto.getUserLimit(), requestDto.getScheduleTime()); → requestDto.getCost()의 반환 타입이 Long인지(컨트롤러/테스트 포함) 확인 후 필요하면 수정하세요.

src/main/java/com/example/onlyone/global/config/RedisLuaConfig.java (1)

13-27: Lua 스크립트 리소스 로딩 OK; 리소스 포함 여부 확인

리소스 경로가 src/main/resources/luascript/*.lua와 일치하는지, JAR에 포함(패키징)되는지 한번만 확인 부탁드립니다.

src/main/java/com/example/onlyone/global/exception/ErrorCode.java (2)

139-141: Outbox 에러 코드 추가 좋습니다

토픽/페이로드 유효성에 대한 명확한 분류가 생겨 운영 가시성에 도움이 됩니다. 컨트롤러/예외핸들러가 각각 400/422를 그대로 HTTP 상태로 매핑하는지 점검만 부탁드립니다.


74-74: 정산 완료 상태 코드 추가 OK — 저장소 메서드와 의미 정합성 확인

markCompleted(...) 저장소 메서드와 이 에러 코드(이미 종료된 정산)의 사용 지점이 일관되게 동작(멱등 완료, 중복 완료 시 409)하는지 확인 바랍니다.

src/main/java/com/example/onlyone/domain/schedule/dto/response/ScheduleResponseDto.java (1)

19-19: 금액 Long 전환 일관성 확보 LGTM

엔티티의 Long(cost)과 DTO가 정합하게 맞춰졌습니다.

Also applies to: 32-32

src/main/java/com/example/onlyone/domain/wallet/entity/Wallet.java (1)

38-43: Long 전환으로 인한 null 허용 위험 — 엔티티/DB 제약(기본값) 추가 필요

int→Long 변경으로 기본값 0이 사라져 null 저장/조회 및 NPE/쿼리 엣지 케이스가 발생할 수 있습니다. 엔티티 필드와 DB 마이그레이션 양쪽에서 NOT NULL + DEFAULT 0(및 타입 bigint) 적용을 권장합니다.

파일: src/main/java/com/example/onlyone/domain/wallet/entity/Wallet.java (라인 38-43)

-    @Column(name = "posted_balance")
-    private Long postedBalance;
+    @NotNull
+    @Column(name = "posted_balance", nullable = false)
+    @Builder.Default
+    private Long postedBalance = 0L;

-    @Column(name = "pending_out")
-    private Long pendingOut;
+    @NotNull
+    @Column(name = "pending_out", nullable = false)
+    @Builder.Default
+    private Long pendingOut = 0L;

또한 Flyway/Liquibase 마이그레이션에 int→bigint 변환과 NOT NULL DEFAULT 0 적용(ALTER/ADD 포함)이 포함됐는지 확인하세요. 저장소에서 Wallet.java/마이그레이션 파일을 찾지 못해 변경 적용 여부를 직접 확인해야 합니다.

src/main/java/com/example/onlyone/global/config/AsyncConfig.java (2)

27-37: 비동기 실행기 구성이 잘 설계되었습니다.

가상 스레드를 사용하면서 세마포어로 동시 실행 상한을 제한하는 접근 방식이 우수하며, Redis 의존성 관리도 적절합니다. @DependsOn 어노테이션으로 Redis 초기화 순서를 보장한 점도 좋습니다.


45-76: Graceful shutdown 로직이 잘 구현되었습니다.

BoundedVtExecutor의 구현이 깔끔합니다. 특히 execute() 메서드에서 호출 스레드를 블로킹하지 않으면서 가상 스레드 내에서 세마포어 대기를 수행하는 방식과, destroy() 메서드의 단계적 종료 로직이 적절합니다.

src/main/java/com/example/onlyone/domain/wallet/service/RedisLuaService.java (2)

44-67: 게이트 패턴 구현이 견고합니다.

Redis Lua 스크립트를 활용한 분산 락 구현이 적절하며, 재시도 로직과 예외 처리도 잘 구성되었습니다. 특히 인터럽트 발생 시 즉시 예외를 던지고 스레드 인터럽트 상태를 복원하는 부분이 우수합니다.


56-63: 랜덤 대기 시간 범위를 검증해보세요.

5-19ms의 대기 시간이 실제 운영 환경에서 적절한지 확인이 필요합니다. 너무 짧을 경우 Redis에 과도한 부하를, 너무 길 경우 응답 지연을 야기할 수 있습니다.

운영 환경에서의 Redis 응답 시간과 동시 접근 패턴을 고려하여 적절한 대기 시간 범위를 설정했는지 검토해보세요.

src/main/java/com/example/onlyone/global/config/kafka/KafkaProperties.java (2)

9-17: 설정 구조가 잘 설계되었습니다.

계층적 설정 구조와 기본값 초기화가 깔끔하게 구성되었습니다. Spring Boot의 Configuration Properties 패턴을 올바르게 활용하고 있습니다.


62-71: 검증 필요 — Kafka Security 필드 유효성 검사

파일: src/main/java/com/example/onlyone/global/config/kafka/KafkaProperties.java (lines 62-71)

Security 내부의 보안 필드(sslTruststoreLocation, sslTruststorePassword, endpointIdentificationAlgorithm, protocol, mechanism, jaas)가 모두 String으로 선언되어 있습니다. 다음 항목을 확인·조치하세요:

  • sslTruststoreLocation: 경로 존재성·읽기 권한·키스토어 포맷 검증 및 로드 실패 안전 처리.
  • sslTruststorePassword / jaas: null/빈값 처리, 시크릿 매니저 연동 또는 암호화·마스킹 적용, 로그/예외에서 마스킹.
  • endpointIdentificationAlgorithm: 허용값 검증(예: "https") 및 대소문자 정규화; 비활성화 허용 시 보안 영향 검토.
  • protocol / mechanism: 허용 목록으로 제한하고 잘못된 값에 대해 실패 처리.
  • 구성 반영 지점 확인: 위 필드들이 실제로 Kafka 클라이언트 설정으로 매핑되는지(Producer/Consumer 초기화 코드) 리포지토리에서 확인하거나 관련 단위/통합 테스트 추가.

검색 스크립트에 출력이 없었음 — 사용처 확인(또는 수동 검증) 필요.

build.gradle (2)

109-109: Preview 기능 활성화가 적절합니다.

StructuredTaskScope 등 Java 21의 preview 기능을 활용하기 위한 설정이 올바르게 구성되었습니다.


62-64: JWT 라이브러리 중복 여부 추가 확인 필요

rg 검색 결과가 비어 있어 0.11.5 존재 여부를 확인할 수 없습니다 — build.gradle(줄 62–64)은 현재 다음과 같습니다:

    implementation 'io.jsonwebtoken:jjwt-api:0.12.4'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.4'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.4'

아래 명령을 레포지토리 루트에서 실행해 전체 검색 및 Gradle 해석 결과를 확인하세요:

rg -n --hidden -S "io\.jsonwebtoken|jjwt"
./gradlew dependencyInsight --dependency io.jsonwebtoken:jjwt-api --configuration runtimeClasspath
./gradlew dependencies --configuration runtimeClasspath | rg "io\.jsonwebtoken|jjwt" -n

멀티모듈일 경우 각 모듈에 대해 ::dependencyInsight 또는 ::dependencies를 실행하세요. 실행 출력을 첨부해 재검토 요청.

Dockerfile (2)

1-2: Base 이미지와 WORKDIR 설정이 적절합니다.

JRE 전용 이미지 사용으로 컨테이너 크기를 최적화하고, WORKDIR 설정으로 명확한 작업 디렉토리를 지정한 것이 좋습니다.


8-8: Preview 기능 활성화가 일관되게 적용되었습니다.

build.gradle에서와 마찬가지로 --enable-preview 플래그가 적용되어 일관성이 유지되었습니다. 다만 timezone 설정이 제거된 이유가 있는지 확인해보세요.

애플리케이션에서 timezone을 별도로 설정하고 있거나, 컨테이너 환경에서 다르게 관리하고 있다면 문제없지만, 필요한 경우 환경변수로 설정하는 것을 고려해보세요.

src/main/java/com/example/onlyone/domain/schedule/service/ScheduleService.java (3)

114-114: 정산 합계 초기화 타입 변경이 적절합니다.

Settlement.sum 필드가 Long 타입으로 변경된 것에 맞춰 초기값을 0L로 설정한 것이 올바릅니다.


143-143: 비용 차이 계산 타입이 적절하게 업데이트되었습니다.

delta 변수가 Long 타입으로 변경되어 Schedule의 cost 필드 타입 변경과 일치합니다.


259-259: 지갑 해제 금액 타입이 일관되게 적용되었습니다.

amount 변수를 Long 타입으로 선언하여 wallet 서비스의 메서드 시그니처와 일치시킨 것이 적절합니다.

src/main/java/com/example/onlyone/domain/user/service/UserService.java (2)

222-222: 지갑 초기 잔액 타입 변경이 적절합니다.

postedBalance100000L로 설정하여 Wallet 엔티티의 Long 타입 필드와 일치시킨 것이 올바릅니다.


266-266: 마이페이지 잔액 조회 로직이 올바르게 업데이트되었습니다.

balance 변수의 타입을 Long으로 변경하고 기본값을 0L로 설정한 것이 Wallet.postedBalance의 타입 변경과 일치합니다.

src/main/java/com/example/onlyone/domain/settlement/entity/OutboxStatus.java (1)

3-7: 상태 정의는 간결하고 적절합니다. 다만 운영 관점에서 상태 의미를 명확히 문서화해 주세요.

NEW→PUBLISHED/FAILED 전이 규칙과 재처리(재시도/보류) 정책이 코드 외부에서 혼동되지 않도록 Javadoc 또는 ADR에 기록을 제안합니다.

src/main/java/com/example/onlyone/domain/wallet/repository/WalletTransactionRepository.java (1)

37-38: 빈 컬렉션 검사·쿼리 distinct 추가·operationId 유니크 인덱스 검토

LedgerWriter에서 findExistingOperationIds가 두 곳에서 호출됩니다 — 상위 호출부가 빈 컬렉션일 때 호출을 생략하는지 확인하세요 (src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java:74, 181–183).
Repository 쿼리에 distinct 추가 권장 (src/main/java/com/example/onlyone/domain/wallet/repository/WalletTransactionRepository.java:37–38) — 제안된 변경은 아래와 같습니다.

-    @Query("select wt.operationId from WalletTransaction wt where wt.operationId in :operationIds")
+    @Query("select distinct wt.operationId from WalletTransaction wt where wt.operationId in :operationIds")
     Set<String> findExistingOperationIds(@Param("operationIds") Collection<String> operationIds);

operationId가 비즈니스 멱등 키로 보이므로 엔티티/DB에 유니크 인덱스 추가해 DB 차원의 멱등성(중복 방지)을 보장하는 방안을 검토하세요.

src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java (1)

98-103: 검증 결과: 동시성 문제 지적은 부정확 — 이미 행 잠금이 적용되어 있습니다.

PaymentService.confirm()이 호출하는 walletRepository.findByUser(User)는 WalletRepository에서 @lock(LockModeType.PESSIMISTIC_WRITE)로 선언되어 있으며 PaymentService는 클래스 레벨 @transactional 하에 실행되어 행 락이 적용됩니다. 따라서 wallet.updateBalance(...)로 인한 레이스 손실 우려는 해소됩니다. 원하면 성능/단순성 관점에서 DB 원자적 증가 메서드 creditByUserId(userId, amount)를 대안으로 사용할 수 있습니다.

참조:

  • src/main/java/com/example/onlyone/domain/wallet/repository/WalletRepository.java — findByUser(User) (@lock(PESSIMISTIC_WRITE)), creditByUserId(...)
  • src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java — confirm() (walletRepository.findByUser(user) 사용)

Likely an incorrect or invalid review comment.

src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (1)

25-33: 에러 처리/재시도 정책 확인 필요

예외를 그대로 throw하지만 컨테이너의 CommonErrorHandler/DefaultErrorHandler(backoff, DLQ 등) 설정이 보이지 않습니다. 배치 처리 특성상 재처리 전략/최대 재시도/백오프/죽은편지 토픽 구성이 필요합니다.

원하시면 KafkaConsumerConfig에 CommonErrorHandler 빈 추가/백오프 정책 샘플을 제안하겠습니다.

src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (2)

28-35: DB 트랜잭션과 Kafka 트랜잭션 원자성 불일치 → 중복 가능성 관리

Kafka 트랜잭션 커밋 성공 후에 Outbox를 PUBLISHED로 바꾸지만, 그 다음 DB 커밋이 실패할 경우 재시도 시 동일 이벤트가 재발행됩니다(At-least-once). 다운스트림(LedgerWriter)에서 완전한 멱등성(operationId 기반)이 확보돼야 합니다.

  • LedgerWriter의 중복 처리 보장 범위를 확인해 주세요.
  • Exactly-once가 필요하면 DB+Kafka ChainedTransactionManager 또는 Transactional Outbox+Relay의 상태전이 강화를 검토하세요(예: PUBLISHING 중간 상태 + 재시도 세마포어).

19-21: Kafka 트랜잭션 ID 설정 관련 주의(교차 파일 참조)

KafkaProducerConfig에서 TRANSACTIONAL_ID_CONFIG에 UUID를 섞어 넣는 방식은 프로듀서 재생성 시 트랜잭션 ID가 증가/누수될 수 있습니다. 일반적으로 transactionIdPrefix만 설정하고 factory가 파티션당/스레드당 suffix를 부여하도록 두는 구성이 안전합니다.

필요 시 안전한 설정 예시를 드리겠습니다.

src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java (2)

129-157: REQUIRES_NEW 선택은 타당. 다만 상태 업데이트 메서드 명세 재확인 필요

createFailedWalletTransactions는 REQUIRES_NEW로 실패 기록을 남기고 상태를 FAILED로 바꿉니다. Repository의 updateStatusIfRequested는 이름과 달리 조건절(REQUESTED)이 없는 것으로 보입니다(레포지토리 코드 참조 필요).

필요 시 조건부 업데이트로 보강하겠습니다.


83-109: 이중 생성 경로 중복 여부 점검

본 서비스에서 성공/실패 트랜잭션을 직접 생성하고, 별도로 Kafka->LedgerWriter 경로도 동일 엔터티를 생성합니다. 두 경로가 동시에 활성화되면 중복 기록 위험이 있습니다.

운영 플로우에서 어떤 경로를 소스 오브 트루스로 둘지 정리 필요합니다(다른 경로는 비활성/삭제).

Also applies to: 129-157

src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (1)

56-56: Redis Lua 게이트 TTL(10초) 적정성 확인 필요

대량 배치/느린 스토리지 상황에서 10초는 부족할 수 있습니다. 프로파일별 외부화(application-*.yml)와 평균/최악 실행시간 기준으로 여유 있게 산정해 주세요.

src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1)

30-31: EOS 보장을 위해 acks 설정 검증

정확히-한번 전송(EOS) 의도라면 acks=all(또는 -1)이 필요합니다. 현재 값이 외부 설정에서 들어오므로 운영 값이 all인지 확인 부탁드립니다.

src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (2)

56-57: 파티션 키 전략 검토

memberWalletId로 파티셔닝하면 “지갑 단위 순서 보장”은 좋으나 동일 정산(settlementId) 내 이벤트가 서로 다른 파티션에 분산될 수 있습니다. LedgerWriter의 처리 가정(주문/정산 단위 순서)이 있다면 settlementId 키 사용을 검토하세요.


3-6: 확인 완료 — OutboxEvent import이 올바릅니다.
OutboxEvent 클래스가 src/main/java/com/example/onlyone/domain/settlement/dto/event/OutboxEvent.java에 정의되어 있어 com.example.onlyone.domain.settlement.dto.event.OutboxEvent import와 일치합니다.

src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (2)

106-108: StructuredTaskScope 사용 허용 — preview 활성화 확인

build.gradle에서 Java 21(라인 13) 및 컴파일(라인 109)/테스트(라인 128)/bootRun(라인 138)에 --enable-preview가 설정되어 있어 JDK21 프리뷰 API(StructuredTaskScope) 사용이 허용됩니다.


79-80: producer 프로퍼티 경로 사용 의도 확인

KafkaProperties에 producer.settlementProcessProducerConfig가 정의되어 있습니다 (src/main/java/com/example/onlyone/global/config/kafka/KafkaProperties.java:28). 하지만 리스너에서 topics = "#{@kafkaProperties.producer.settlementProcessProducerConfig.topic}"로 producer 쪽 설정을 참조합니다 (src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java:79-80). 프로듀서/컨슈머 프로퍼티가 분리된 경우 오타(consumer 대신 producer 참조)일 수 있으니 의도된 동작인지 확인 바랍니다.

src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java (1)

94-101: 참가자 ID 종류(userId vs userSettlementId) 불일치 가능 — 확인 필요

findAllUserSettlementIdsBySettlementIdAndStatus는 이름·시그니처상 userSettlementId를 반환하는 것처럼 보입니다. SettlementService의 변수명 targetUserIds와 이후 파라미터(participantId)가 userId를 기대하면 ID 종류가 어긋납니다.

  • 확인: UserSettlementRepository의 SELECT가 userSettlement.id인지(=userSettlementId) 또는 us.user.id(=userId)인지 확인.
  • 조치(결과에 따라 하나 선택):
    • 반환값이 userSettlementId면 이벤트/변수명을 targetUserSettlementIds로 변경하거나 수신 측에서 매핑 추가.
    • 반환값이 userId여야 하면 리포지토리 쿼리를 user.userId를 반환하도록 수정.
      위치: src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java (94-101, 125-136), src/main/java/com/example/onlyone/domain/settlement/repository/UserSettlementRepository.java (메서드 선언).
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)

28-60: 보안 설정 분기 검증 — truststore 경로/패스워드 존재 여부 확인 필요

  • 보안 활성화 시 ssl.truststore.location과 ssl.truststore.password가 null 또는 빈 문자열(환경변수 배포에서 빈값 주입 가능)이 아닌지 모두 검증하라.
  • 현재 src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (lines 28–60)는 location만 isBlank로 검사하고 password는 바로 사용하므로, password도 isBlank/hasText 검사 후에만 config에 추가하거나 빈값일 때 예외 발생 또는 명확한 로깅을 추가하라.
  • 리포지토리 검색이 실패해 application*.yml/properties에서 키 확인을 못했으니, application.yml/properties 또는 배포 환경변수에서 ssl.truststore.* 및 sasl.jaas 설정이 올바르게 채워지는지 수동으로 확인하라.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (6)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)

12-12: 금액 타입 일관성 정리 완료(LGTM).

기존 코멘트에서 지적된 amount 타입 불일치가 해소되었습니다. 이벤트 DTO 전반이 Long으로 통일되어 직렬화/오버플로 리스크가 줄었습니다.

src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)

6-15: 실패 사유(reason)/시점 누락으로 사후 분석 어려움.

실패 이벤트에 최소한 errorCode/reason, occurredAt을 포함하세요. 운영, 재시도, 알림에서 필수입니다.

 package com.example.onlyone.domain.settlement.dto.event;
 
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import java.time.Instant;
 
 @Data
 @AllArgsConstructor
 public class WalletCaptureFailedEvent {
     private Long userSettlementId;
     private Long memberWalletId;
     private Long leaderWalletId;
     private Long amount;
     private Long memberBalanceBefore;
     private Long leaderBalanceBefore;
+    private String reason;     // ex) "INSUFFICIENT_BALANCE"
+    private Instant occurredAt;
 }
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)

116-121: 인터럽트 친화적 세마포어 획득으로 수정된 점 확인(LGTM).

acquireUninterruptibly() → acquire()로 변경되어 StructuredTaskScope 취소 신호를 존중합니다.

src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)

23-31: 실패 사유(reason) 페이로드 누락 — 재현됨.

지난 코멘트와 동일 이슈입니다. reason을 메서드 시그니처에 추가하고 JSON에 주입하세요. 또한 파티션 키를 memberWalletId가 아닌 이벤트 키(operationId)로 맞춰 멱등/순서를 보장하는 편이 안전합니다.

-    public void appendFailedUserSettlementEvent(Long settlementId,
+    public void appendFailedUserSettlementEvent(Long settlementId,
                                                 Long userSettlementId,
                                                 Long participantId,
                                                 Long memberWalletId,
                                                 Long leaderId,
                                                 Long leaderWalletId,
-                                                Long amount) {
+                                                Long amount,
+                                                String reason) {
         try {
-            // 1. DTO로 변환
+            // 1. DTO로 변환
+            String eventKey = "stl:%d:usr:%d:v1".formatted(settlementId, participantId);
             UserSettlementStatusEvent eventDto = new UserSettlementStatusEvent(
                     UserSettlementStatusEvent.ResultType.FAILED,
-                    "stl:%d:usr:%d:v1".formatted(settlementId, participantId),
+                    eventKey,
                     Instant.now(),
                     settlementId,
                     userSettlementId,
                     participantId,
                     memberWalletId,
                     leaderId,
                     leaderWalletId,
                     amount
             );
 
-            // 2. JSON 직렬화
-            String json = objectMapper.writeValueAsString(eventDto);
+            // 2. JSON 직렬화 (+reason 주입)
+            var node = objectMapper.valueToTree(eventDto);
+            ((com.fasterxml.jackson.databind.node.ObjectNode) node).put("reason", reason);
+            String json = objectMapper.writeValueAsString(node);
 
             // 3. OutboxEvent 저장
             OutboxEvent event = OutboxEvent.builder()
                     .aggregateType("UserSettlement")
                     .aggregateId(userSettlementId)
                     .eventType("ParticipantSettlementResult")
-                    .keyString(String.valueOf(memberWalletId)) // partition key
+                    .keyString(eventKey) // partition key = operationId
                     .payload(json)
                     .status(OutboxStatus.NEW)
                     .createdAt(LocalDateTime.now())
                     .build();
src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (1)

100-106: FAILED 상태 업데이트가 롤백됨 → 이벤트/상태 불일치 가능.

현재 메서드 전체가 REQUIRES_NEW이므로 catch에서 상태를 FAILED로 바꾼 뒤 예외를 던지면 동일 트랜잭션 롤백으로 상태 변경이 사라집니다. 반면 FailedEventAppender는 별도(REQUIRES_NEW)로 커밋되어 불일치가 발생할 수 있습니다. 별도 트랜잭션에서 상태를 저장하세요.

-            } catch (Exception e) {
-                userSettlementRepository.updateStatusIfRequested(us.getUserSettlementId(), SettlementStatus.FAILED);
-                failedEventAppender.appendFailedUserSettlementEvent(
-                        settlementId, us.getUserSettlementId(), participantId,
-                        memberWalletId, leaderId, leaderWalletId, amount
-                );
-                throw e;
-            }
+            } catch (Exception e) {
+                // 1) 상태는 별도 트랜잭션에서 확정
+                markUserSettlementFailedInNewTx(us.getUserSettlementId());
+                // 2) 실패 이벤트는 예외 삼켜서 원본 예외 보존 + reason 전달
+                try {
+                    failedEventAppender.appendFailedUserSettlementEvent(
+                            settlementId, us.getUserSettlementId(), participantId,
+                            memberWalletId, leaderId, leaderWalletId, amount,
+                            e.getClass().getSimpleName()
+                    );
+                } catch (Exception appendEx) {
+                    log.warn("Failed to append FAILED event (userSettlementId={}): {}", us.getUserSettlementId(), appendEx.getMessage(), appendEx);
+                }
+                throw e;
+            }

추가(클래스 내부, 선택 영역 밖):

@Transactional(propagation = Propagation.REQUIRES_NEW)
void markUserSettlementFailedInNewTx(Long userSettlementId) {
    userSettlementRepository.updateStatusIfRequested(userSettlementId, SettlementStatus.FAILED);
}
src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (1)

25-26: operationId 컬럼: 길이/NULL/업데이트 불가 제약 추가 필요

중복 방지 키로 쓰이므로 DB 스키마 보강이 필요합니다. 현재 unique만 있고 길이/NULL/업데이트 불가가 없습니다.

적용 제안(diff):

-    @Column(name = "operation_id", unique = true)
-    private String operationId;
+    @Column(name = "operation_id", length = 128, nullable = false, updatable = false, unique = true)
+    @NotNull
+    private String operationId;

DB 마이그레이션(DDL)로도 동일 제약(length/NOT NULL/UNIQUE INDEX) 추가 부탁드립니다. 필요 시 스크립트 생성 도와드릴게요.

🧹 Nitpick comments (11)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)

6-14: 이벤트 스키마에 식별·시점 메타데이터 추가 제안(선택).

하류(idempotency/모니터링)에서 유용한 operationId, occurredAt(Instant) 필드를 포함하면 추적성이 좋아집니다. 필요 시 아래처럼 확장하세요.

 package com.example.onlyone.domain.settlement.dto.event;
 
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import java.time.Instant;
 
 @Data
 @AllArgsConstructor
 public class WalletCaptureSucceededEvent {
     private Long userSettlementId;
     private Long memberWalletId;
     private Long leaderWalletId;
     private Long amount;
     private Long memberBalanceAfter;
     private Long leaderBalanceAfter;
+    private String operationId;   // ex) "stl:%d:usr:%d:v1"
+    private Instant occurredAt;
 }
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)

16-22: OutboxAppender 재사용으로 중복 제거 제안.

동일한 outbox 저장 로직이 OutboxAppender에 이미 있습니다. 해당 빈을 주입해 append(...)를 호출하면 코드 중복과 예외 처리 표준화가 됩니다.

src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1)

82-92: 배치 처리 중 일부 성공/일부 실패 시 재처리 전략 명확화 필요.

현재는 하나라도 실패하면 ack 미호출로 배치 전체가 재소비됩니다. 이미 성공한 이벤트의 재처리를 허용할 멱등키가 없다면 중복 효과가 날 수 있습니다. DLQ/재시도 토폴로지를 분리하거나, 레코드 단위 에러 핸들러를 고려하세요.

src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (2)

81-99: Outbox 키를 operationId로 통일하여 멱등·순서를 보장.

현재 keyString이 memberWalletId입니다. 소비자 측 dedupe/순서를 안정적으로 하려면 operationId("stl:%d:usr:%d:v1")를 키로 사용하는 편이 적절합니다.

                 outboxAppender.append(
                         "UserSettlement",
                         us.getUserSettlementId(),
                         "ParticipantSettlementResult",
-                        String.valueOf(memberWalletId),
+                        operationId,
                         Map.of(
                                 "type", "SUCCESS",
                                 "operationId", operationId,
                                 "occurredAt", java.time.Instant.now().toString(),
                                 "settlementId", settlementId,
                                 "userSettlementId", us.getUserSettlementId(),
                                 "participantId", participantId,
                                 "memberWalletId", memberWalletId,
                                 "leaderId", leaderId,
                                 "leaderWalletId", leaderWalletId,
                                 "amount", amount
                         )
                 );

81-99: Map.of 항목 수 한계(최대 10개) 근접 — 향후 필드 추가 시 실패 위험.

현재 10개로 한계치입니다. 필드가 늘 가능성이 있으면 Map.ofEntries로 변경해 미래 오류를 방지하세요.

-                        Map.of(
-                                "type", "SUCCESS",
-                                "operationId", operationId,
-                                "occurredAt", java.time.Instant.now().toString(),
-                                "settlementId", settlementId,
-                                "userSettlementId", us.getUserSettlementId(),
-                                "participantId", participantId,
-                                "memberWalletId", memberWalletId,
-                                "leaderId", leaderId,
-                                "leaderWalletId", leaderWalletId,
-                                "amount", amount
-                        )
+                        Map.ofEntries(
+                                Map.entry("type", "SUCCESS"),
+                                Map.entry("operationId", operationId),
+                                Map.entry("occurredAt", java.time.Instant.now().toString()),
+                                Map.entry("settlementId", settlementId),
+                                Map.entry("userSettlementId", us.getUserSettlementId()),
+                                Map.entry("participantId", participantId),
+                                Map.entry("memberWalletId", memberWalletId),
+                                Map.entry("leaderId", leaderId),
+                                Map.entry("leaderWalletId", leaderWalletId),
+                                Map.entry("amount", amount)
+                        )
src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (6)

18-19: 중복 로깅 라이브러리 임포트 정리

@log는 미사용이고 @log4j2만 사용 중입니다. 불필요 임포트 제거하세요.

-import lombok.extern.java.Log;
 import lombok.extern.log4j.Log4j2;

66-72: operationId 후보 수집: 네이밍/로깅/검증 강화

  • 변수명 오타(candidateoperationIds) → candidateOperationIds.
  • 빈/공백 operationId 스킵 시 경고 로그 남기기.
-        Set<String> candidateoperationIds = new HashSet<>();
+        Set<String> candidateOperationIds = new HashSet<>();
         for (JsonNode root : events) {
             String operationId = root.path("operationId").asText();
-            if (operationId == null || operationId.isBlank()) continue;
-            candidateoperationIds.add(operationId + ":OUT");
-            candidateoperationIds.add(operationId + ":IN");
+            if (operationId == null || operationId.isBlank()) {
+                log.warn("Skip event without operationId: {}", root);
+                continue;
+            }
+            candidateOperationIds.add(operationId + ":OUT");
+            candidateOperationIds.add(operationId + ":IN");
         }

그리고 아래 조회부도 변수명 일치:

-        Set<String> existing = new HashSet<>(walletTransactionRepository.findExistingOperationIds(candidateoperationIds));
+        Set<String> existing = new HashSet<>(walletTransactionRepository.findExistingOperationIds(candidateOperationIds));

77-140: 동일 배치 내 중복 생성 사전 차단(seen 세트 도입)

DB 중복은 unique로 막히지만 saveAll에서 예외→fallback 경로로 빠져 성능/복잡도를 악화시킵니다. 동일 배치에서 outId/inId를 한 번만 생성하도록 seen 집합으로 차단하세요.

적용 제안(diff):

-        Map<String, WalletTransaction> walletTransactionHashMap = new HashMap<>();
+        Set<String> seen = new HashSet<>(existing);
         for (JsonNode root : events) {
             String type = root.path("type").asText("SUCCESS");
             String operationId = root.path("operationId").asText();
             if (operationId == null || operationId.isBlank()) continue;

@@
             // OUTGOING
             String outId = operationId + ":OUT";
-            if (!existing.contains(outId)) {
+            if (!existing.contains(outId) && seen.add(outId)) {
                 WalletTransaction outTransaction = WalletTransaction.builder()
@@
                 walletTransactionList.add(outTransaction);
-                walletTransactionHashMap.put(outId, outTransaction);
@@
             }
             // INCOMING
             String inId = operationId + ":IN";
-            if (!existing.contains(inId)) {
+            if (!existing.contains(inId) && seen.add(inId)) {
                 WalletTransaction inTransaction = WalletTransaction.builder()
@@
                 walletTransactionList.add(inTransaction);
-                walletTransactionHashMap.put(inId, inTransaction);
@@
             }

또한 미사용 변수 walletTransactionHashMap은 제거하세요.


152-166: Transfer 저장 예외 삼킴: 가시성 부족

DataIntegrityViolationException을 무시하면 원인 분석이 어렵습니다. 최소 warn 로그를 남기고 필요 시 개별 재시도하세요.

-            } catch (DataIntegrityViolationException dup) {
-                // 필요시 개별 재시도
-            }
+            } catch (DataIntegrityViolationException dup) {
+                log.warn("Transfer batch save failed, will skip duplicates. size={}", transferList.size(), dup);
+                // 필요 시 개별 재시도 로직 추가 고려
+            }

89-92: getReferenceById 사용 시 존재하지 않는 참조 처리

존재하지 않는 ID일 경우 지연접근 시점에 예외가 발생합니다. 이벤트 유효성 보장을 위해 존재 확인(findById orExists) 후 명확한 예외/격리 처리를 권장합니다.


143-150: saveAll 후 flush 순서 OK, 단 예외 시 컨텍스트 정리 필요

예외 발생 시 EntityManager clear/close 없이 후속 작업을 계속하면 부작용이 있습니다. 위에서 제안한 트랜잭션 경계 재설계와 함께 고려하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb7de53 and 3cd7da7.

📒 Files selected for processing (12)
  • .gitignore (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/repository/OutboxRepository.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (1 hunks)
  • src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (2 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java (1 hunks)
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • .gitignore
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaTopicConfig.java
  • src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java
  • src/main/java/com/example/onlyone/domain/settlement/repository/OutboxRepository.java
  • src/main/java/com/example/onlyone/global/config/kafka/KafkaProducerConfig.java
🧰 Additional context used
🧬 Code graph analysis (6)
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)
src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1)
  • Service (13-39)
src/main/java/com/example/onlyone/domain/settlement/service/UserSettlementService.java (2)
src/main/java/com/example/onlyone/domain/settlement/service/FailedEventAppender.java (1)
  • Service (16-66)
src/main/java/com/example/onlyone/domain/settlement/service/OutboxAppender.java (1)
  • Service (13-39)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementKafkaEventListener.java (3)
src/main/java/com/example/onlyone/domain/wallet/service/RedisLuaService.java (1)
  • Component (15-74)
src/main/java/com/example/onlyone/domain/settlement/service/SettlementProcessEventListener.java (1)
  • Component (30-134)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1)
  • Component (15-51)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (2)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/domain/settlement/dto/event/SettlementProcessEvent.java (1)
  • Data (8-19)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureFailedEvent.java (1)
src/main/java/com/example/onlyone/domain/settlement/dto/event/WalletCaptureSucceededEvent.java (1)
  • Data (6-15)
src/main/java/com/example/onlyone/domain/settlement/service/LedgerWriter.java (3)
src/main/java/com/example/onlyone/global/config/kafka/KafkaConsumerConfig.java (1)
  • RequiredArgsConstructor (20-89)
src/main/java/com/example/onlyone/domain/settlement/service/KafkaService.java (1)
  • Log4j2 (14-41)
src/main/java/com/example/onlyone/global/config/kafka/OutboxRelayConfig.java (1)
  • Component (15-51)
🔇 Additional comments (2)
src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (2)

33-36: int→Long 전환에 따른 DB 스키마 정합성 확인

JPA 필드 타입은 Long으로 변경되었는데, DB 컬럼이 BIGINT로 마이그레이션되었는지 확인 필요합니다. 또한 음수 허용 여부(부채/환불 등) 정책 점검 바랍니다.

Also applies to: 37-40


81-88: src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransaction.java (lines ~81-88) — targetWallet 할당 버그 및 updatable=false 충돌 확인 필요

  • 문제: update(...)가 targetWallet을 wallet로 덮어써 양측 지갑이 동일해짐. 시그니처에 targetWallet을 추가해 할당해야 함. 제안 수정(diff):
-    public void update(Type type, Long amount, Long postedBalance, WalletTransactionStatus walletTransactionStatus, Wallet wallet) {
+    public void update(Type type, Long amount, Long postedBalance, WalletTransactionStatus walletTransactionStatus,
+                       Wallet wallet, Wallet targetWallet) {
         this.type = type;
         this.amount = amount;
         this.balance = postedBalance;
         this.walletTransactionStatus = walletTransactionStatus;
         this.wallet = wallet;
-        this.targetWallet = wallet;
+        this.targetWallet = targetWallet;
     }
  • 추가 확인: wallet/targetWallet 컬럼이 updatable=false이면 이 메서드로 DB 반영이 되지 않을 수 있음. 호출부 확인이 필요하며(자동 검색 결과 호출부 없음), update가 실제로 호출되는지 또는 생성 시에만 설정하는 설계인지 확정하세요.

Copy link
Contributor

@NamYeonW00 NamYeonW00 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다~~!!

@gkdudans gkdudans merged commit 187ff5b into develop Sep 16, 2025
1 check was pending
choigpt pushed a commit that referenced this pull request Sep 17, 2025
…s-kafka2

[feat] 자동 정산 기능 최적화
@coderabbitai coderabbitai bot mentioned this pull request Sep 17, 2025
13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants